import sys
import os
import json
import time
import traceback
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from sqlalchemy import create_engine
from PySide6.QtWidgets import (
QApplication, QMainWindow, QDialog, QVBoxLayout, QHBoxLayout,
QPushButton, QLineEdit, QTextEdit, QLabel, QFileDialog, QMessageBox,
QMenuBar, QMenu, QWidget, QFormLayout, QComboBox, QSpinBox
)
from PySide6.QtCore import Qt, QThread, Signal
# ==============================================================================
# 【区域一:全局配置与辅助函数(新增代码,三段式注解)】
# ==============================================================================
CONFIG_FILE = "task_config.json"
def load_all_config():
"""
从 JSON 文件加载所有任务的记忆参数。
"""
# ===== 第 1 部分:尝试打开配置文件并读取 JSON =====
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f: # ① 以只读模式打开配置文件
return json.load(f) # ② 将文件内容解析为 Python 字典
# ===== 作用总结:如果配置文件存在且内容合法,返回记忆参数字典 =====
# ===== 第 2 部分:处理文件不存在或格式错误的情况 =====
except (FileNotFoundError, json.JSONDecodeError):
return {} # ① 返回空字典,表示没有任何记忆参数
# ===== 作用总结:保证程序启动时不会因为配置文件问题而崩溃 =====
def save_all_config(config_dict):
"""
将所有任务的记忆参数保存到 JSON 文件。
"""
# ===== 第 1 部分:将字典写入 JSON 文件 =====
with open(CONFIG_FILE, 'w', encoding='utf-8') as f: # ① 以写入模式打开文件
json.dump(config_dict, f, indent=4, ensure_ascii=False) # ② 将字典序列化并格式化写入
# ===== 作用总结:将当前所有任务的参数值永久保存到硬盘,供下次启动时读取 =====
# ==============================================================================
# 【区域二:任务定义列表(新增代码,三段式注解)】
# ==============================================================================
# 每个任务由以下字段描述:
# - id : 唯一标识符,用于记忆参数
# - name : 显示在菜单上的名称
# - category : 菜单分类(如 "数据清洗"、"数据分析"),用于分组
# - description : 任务简短描述(鼠标悬停提示)
# - params : 参数列表,每个参数是一个字典,包含:
# * name : 参数变量名(传给处理函数的键名)
# * label : 界面显示标签
# * type : 参数类型,支持 "file"(文件选择)、"folder"(文件夹选择)、"text"(普通文本)、"int"(整数)、"combo"(下拉框)
# * default : 默认值(若配置文件中无记忆值)
# * required : 是否必填(True/False)
# * options : 仅当 type 为 "combo" 时有效,列表形式,如 ["选项1","选项2"]
# - function : 实际执行数据处理的函数名称(字符串,稍后映射到实际函数)
TASK_LIST = [
{
"id": "transaction_import",
"name": "交易记录整理写入",
"category": "数据清洗",
"description": "从导出的Excel文件整理交易记录并写入数据库",
"params": [
{"name": "path_ot", "label": "交易记录文件", "type": "file", "default": "", "required": True},
{"name": "path_itp", "label": "采购商清单文件", "type": "file", "default": "", "required": True},
{"name": "path_its", "label": "客户清单文件", "type": "file", "default": "", "required": True},
],
"function": "task_transaction_import"
},
{
"id": "weighted_avg_price",
"name": "加权平均价计算",
"category": "数据分析",
"description": "从数据库读取销售订单,计算近三年加权平均价并导出Excel",
"params": [
{"name": "db_host", "label": "数据库主机", "type": "text", "default": "localhost", "required": True},
{"name": "db_port", "label": "端口", "type": "int", "default": 3306, "required": True},
{"name": "db_user", "label": "用户名", "type": "text", "default": "root", "required": True},
{"name": "db_password", "label": "密码", "type": "text", "default": "502", "required": True},
{"name": "db_name", "label": "数据库名", "type": "text", "default": "shn", "required": True},
{"name": "output_dir", "label": "输出文件夹", "type": "folder", "default": "", "required": True},
],
"function": "task_weighted_avg_price"
},
]
# ==============================================================================
# 【区域三:用户原始数据处理函数(用户提供,不做修改,不添加注解)】
# ==============================================================================
def task_transaction_import(params, log_signal, stop_flag):
"""
===== 用户提供的第一段代码:交易记录整理写入 =====
"""
import pandas as pd
import numpy as np
from sqlalchemy import create_engine
import time
try:
log_signal.emit("开始读取交易记录文件...")
df_ot = pd.read_excel(params["path_ot"])
if stop_flag['stop']: return
log_signal.emit("开始读取采购商清单...")
df_itp = pd.read_excel(params["path_itp"])
if stop_flag['stop']: return
log_signal.emit("开始读取客户清单...")
df_its = pd.read_excel(params["path_its"])
if stop_flag['stop']: return
log_signal.emit("正在合并数据...")
df_itp1 = df_itp.loc[:, ["供应商帐户", "供应商分类", "名称"]]
df_its1 = df_its.loc[:, ["客户帐户", "名称", "搜索名称", "客户录入", "隶属集团客户"]]
df1 = pd.merge(
left=df_ot, right=df_itp1, how='left',
left_on="帐号", right_on="供应商帐户"
).rename(columns={"名称": "供应商名称"}).drop("供应商帐户", axis=1)
df2 = pd.merge(
left=df1, right=df_its1, how='left',
left_on="帐号", right_on="客户帐户"
).rename(columns={"名称": "客户名称"}).drop("客户帐户", axis=1)
del df_itp, df_its
if stop_flag['stop']: return
def return_row(group):
for _, row in group.iterrows():
if row['引用'] in ('生产', '采购订单', '销售订单'):
return row
return None
log_signal.emit("正在处理订单引用...")
pro = df_ot.groupby('编号').apply(return_row)
del df_ot
pro1 = pro.iloc[:, 2]
df_pro = pd.merge(df2, pro1, how='left', left_on="编号", right_on="编号")
df_pro1 = df_pro.rename(columns={"物料编号_x": "物料编号", "物料编号_y": "产品编号"}).reset_index()
df_pro1["年月"] = pd.to_datetime(df_pro1.财务日期).dt.strftime("%Y%m")
df_pro1["年"] = pd.to_datetime(df_pro1.财务日期).dt.strftime("%Y")
df_pro1 = df_pro1.drop("index", axis=1)
df_pro1["调整"] = df_pro1["调整"].fillna(0)
df_pro1["成本额"] = df_pro1["成本额"].fillna(0)
df_pro1["总成本金额"] = df_pro1["成本额"] + df_pro1["调整"]
column_order = [
"引用", "编号", "物料编号", "交易记录ID", "仓库", "批处理号", "库位", "类型", "数量",
"实际日期", "财务日期", "实际成本额", "成本额", "调整", "站点", "财务凭证", "实际凭证",
"帐号", "总成本金额", "年", "年月", "供应商分类", "供应商名称", "客户名称",
"搜索名称", "客户录入", "隶属集团客户", "产品编号"
]
df_result = df_pro1[column_order].rename(columns={
"引用": "ct_mobileType", "编号": "ct_PO", "物料编号": "ct_ItemRelation",
"交易记录ID": "ct_id", "仓库": "ct_warehouse", "批处理号": "ct_batchNumber",
"库位": "ct_warehouseLocation", "类型": "ct_productionType", "数量": "ct_quantity",
"实际日期": "ct_actualDate", "财务日期": "ct_financialDate",
"实际成本额": "ct_actualCostAmount", "成本额": "ct_costAmount",
"调整": "ct_adjustingCosts", "站点": "ct_dataAreaId",
"财务凭证": "ct_financialVouchers", "实际凭证": "ct_actualVoucher",
"帐号": "ct_accounts", "总成本金额": "ct_totalCostAmount", "年": "ct_year",
"年月": "ct_yearAndMonth", "供应商分类": "ct_supplierClassification",
"供应商名称": "ct_supplierName", "客户名称": "ct_customerName",
"搜索名称": "ct_customerAbbreviation", "客户录入": "ct_customerInput",
"隶属集团客户": "ct_affiliatedGroupCustomers", "产品编号": "ct_productNumber"
})
if stop_flag['stop']: return
log_signal.emit("正在写入数据库...")
engine = create_engine('mysql+mysqlconnector://root:502@localhost:3306/shn')
df_result.to_sql('ct', con=engine, if_exists='append', index=False)
log_signal.emit("数据库写入完成。")
except Exception as e:
log_signal.emit(f"处理出错: {str(e)}\n{traceback.format_exc()}")
def task_weighted_avg_price(params, log_signal, stop_flag):
"""
===== 用户提供的第二段代码:加权平均价计算 =====
"""
import pandas as pd
import numpy as np
from sqlalchemy import create_engine
from datetime import datetime, timedelta
import os
try:
conn_str = f"mysql+mysqlconnector://{params['db_user']}:{params['db_password']}@{params['db_host']}:{params['db_port']}/{params['db_name']}"
engine = create_engine(conn_str)
log_signal.emit("已连接到数据库。")
log_signal.emit("正在读取销售订单数据...")
dfs0 = pd.read_sql('SELECT * FROM ct WHERE ct_mobileType = "销售订单"', con=engine)
if stop_flag['stop']: return
def get_last_day_of_last_month(date):
first_day_of_month = date.replace(day=1)
return first_day_of_month - timedelta(days=1)
today = datetime.now()
last_day = get_last_day_of_last_month(today)
y3 = pd.to_datetime(last_day)
three_years_ago = y3 - pd.DateOffset(years=3)
log_signal.emit(f"计算基准日期:上月末 {last_day.strftime('%Y-%m-%d')},三年前 {three_years_ago.strftime('%Y-%m-%d')}")
dfs0['ct_financialDate'] = pd.to_datetime(dfs0['ct_financialDate'])
dfs0['is_recent'] = dfs0['ct_financialDate'] > three_years_ago
recent_df = dfs0[dfs0['is_recent']].copy()
older_df = dfs0[~dfs0['is_recent']].copy()
recent_df['ct_totalCostAmount'] = recent_df['ct_totalCostAmount'].fillna(0)
older_df['ct_totalCostAmount'] = older_df['ct_totalCostAmount'].fillna(0)
recent_df = recent_df[recent_df.ct_totalCostAmount != 0]
older_df = older_df[older_df.ct_totalCostAmount != 0]
if stop_flag['stop']: return
recent_df["avge"] = recent_df.ct_totalCostAmount / recent_df.ct_quantity
older_df["avge"] = older_df.ct_totalCostAmount / older_df.ct_quantity
dfr1 = recent_df.groupby("ct_ItemRelation").agg(
总金额=("ct_totalCostAmount", "sum"),
总数量=("ct_quantity", "sum"),
最大平均值=("avge", "max"),
最小平均值=("avge", "min")
).reset_index()
dfr1["平均值"] = dfr1["总金额"] / dfr1["总数量"]
dfo1 = older_df.groupby("ct_ItemRelation").agg(
总金额=("ct_totalCostAmount", "sum"),
总数量=("ct_quantity", "sum"),
最大平均值=("avge", "max"),
最小平均值=("avge", "min")
).reset_index()
dfo1["平均值"] = dfo1["总金额"] / dfo1["总数量"]
dfo1 = dfo1.drop_duplicates('ct_ItemRelation')
dfo1["价格状态"] = "近3年历史价格"
all_items = dfs0[["ct_ItemRelation"]].drop_duplicates()
recent_items = dfr1["ct_ItemRelation"].unique()
old_only_items = all_items[~all_items["ct_ItemRelation"].isin(recent_items)].copy()
old_only_items["价格状态"] = "3年前历史价格"
dfs_old_only = pd.merge(old_only_items, dfo1, on="ct_ItemRelation", how="left")
dfo1["价格类型"] = "销售价"
dfs_old_only["价格类型"] = "销售价"
final_result = pd.concat([dfo1, dfs_old_only], ignore_index=True)
if stop_flag['stop']: return
output_dir = params["output_dir"]
os.makedirs(output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
recent_df.to_excel(os.path.join(output_dir, f"近三年销售明细_{timestamp}.xlsx"), index=False)
older_df.to_excel(os.path.join(output_dir, f"三年前销售明细_{timestamp}.xlsx"), index=False)
final_result.to_excel(os.path.join(output_dir, f"加权平均价结果_{timestamp}.xlsx"), index=False)
log_signal.emit(f"结果已保存至文件夹:{output_dir}")
except Exception as e:
log_signal.emit(f"处理出错: {str(e)}\n{traceback.format_exc()}")
# 将函数名字符串映射到实际函数对象(新增代码,三段式注解)
FUNCTION_MAP = {
# ===== 第 1 部分:键是字符串 =====
"task_transaction_import": task_transaction_import, # ① 映射到交易记录处理函数
"task_weighted_avg_price": task_weighted_avg_price, # ② 映射到加权平均价计算函数
# ===== 作用总结:通过字典将配置中的函数名字符串转换为可调用的函数对象 =====
}
# ==============================================================================
# 【区域四:通用工作线程类(新增代码,三段式注解)】
# ==============================================================================
class WorkerThread(QThread):
"""
后台工作线程,负责执行耗时的数据处理任务,不阻塞界面。
"""
# ===== 第 1 部分:定义信号 =====
log_signal = Signal(str) # ① 创建一个能发送字符串的信号,用于向主界面发送日志
finished_signal = Signal() # ② 创建一个无参数的信号,用于通知任务结束
# ===== 作用总结:通过信号实现线程安全的界面更新 =====
def __init__(self, task_func, params):
"""
参数:
task_func : 要执行的处理函数
params : 参数字典
"""
# ===== 第 1 部分:调用父类构造函数 =====
super().__init__() # ① 必须调用 QThread 的初始化,使对象具备线程能力
# ===== 作用总结:确保线程对象正确创建 =====
# ===== 第 2 部分:保存传入的参数 =====
self.task_func = task_func # ① 将处理函数保存为实例变量,供 run() 调用
self.params = params # ② 将参数字典保存为实例变量
# ===== 作用总结:让线程在运行时能够访问到需要执行的函数和参数 =====
# ===== 第 3 部分:创建停止标志字典 =====
self.stop_flag = {'stop': False} # ① 使用字典包装布尔值,使函数内部修改能被外部感知
# ===== 作用总结:提供一个可变容器,用于在任务执行过程中传递停止指令 =====
def run(self):
"""
线程启动后自动执行的函数。此方法在后台线程中运行。
"""
# ===== 第 1 部分:记录开始时间 =====
start_time = time.time() # ① 获取当前时间戳,用于计算总耗时
# ===== 作用总结:为稍后输出运行时长做准备 =====
# ===== 第 2 部分:发送开始日志 =====
self.log_signal.emit("任务开始执行...") # ① 通过信号将字符串发送到主界面
# ===== 作用总结:让用户知道任务已启动 =====
# ===== 第 3 部分:执行用户提供的处理函数 =====
try:
self.task_func(self.params, self.log_signal, self.stop_flag) # ① 调用处理函数,传入参数、日志信号和停止标志
except Exception as e:
self.log_signal.emit(f"未捕获的异常: {str(e)}") # ② 如果处理函数抛出异常,捕获并显示
# ===== 作用总结:实际执行用户的数据处理逻辑 =====
# ===== 第 4 部分:任务结束后的处理 =====
finally:
if not self.stop_flag['stop']: # ① 检查是否因用户停止而结束
duration = time.time() - start_time # ② 计算运行时长
self.log_signal.emit(f"运行时长: {duration:.2f}秒") # ③ 发送耗时信息
self.finished_signal.emit() # ④ 发送任务完成信号
# ===== 作用总结:正常结束时显示耗时,并通知主界面任务已结束 =====
def stop(self):
"""
供外部调用的停止方法。
"""
# ===== 第 1 部分:修改停止标志 =====
self.stop_flag['stop'] = True # ① 将字典中的布尔值设为 True
# ===== 作用总结:处理函数内部会定期检查该标志,发现为 True 时提前退出 =====
# ===== 第 2 部分:发送日志提示 =====
self.log_signal.emit("正在中止,请稍候...") # ① 通过信号告知用户已收到停止指令
# ===== 作用总结:提供界面反馈,表明停止操作已被接受 =====
# ==============================================================================
# 【区域五:动态参数输入对话框类(新增代码,三段式注解)】
# ==============================================================================
class TaskDialog(QDialog):
"""
根据任务配置动态生成参数输入界面的对话框。
"""
def __init__(self, task_config, parent=None):
# ===== 第 1 部分:调用父类构造函数并设置窗口属性 =====
super().__init__(parent) # ① 初始化 QDialog 基类
self.task_config = task_config # ② 保存传入的任务配置字典
self.setWindowTitle(task_config["name"]) # ③ 设置对话框标题为任务名称
self.setMinimumWidth(600) # ④ 设置最小宽度,防止布局变形
# ===== 作用总结:创建一个标题为任务名称的对话框窗口 =====
# ===== 第 2 部分:加载该任务的记忆参数 =====
self.all_config = load_all_config() # ① 从文件读取所有任务的记忆字典
task_id = task_config["id"] # ② 获取当前任务的唯一标识符
self.saved_params = self.all_config.get(task_id, {}) # ③ 从总配置中取出该任务的记忆参数,若无则为空字典
# ===== 作用总结:获取该任务上次保存的参数值,用于预填充输入框 =====
# ===== 第 3 部分:初始化成员变量 =====
self.param_widgets = {} # ① 字典,用于存储参数名与对应输入控件的映射,方便后续取值
self.worker = None # ② 初始化为 None,表示当前没有后台线程在运行
# ===== 作用总结:为界面控件管理和线程控制做准备 =====
# ===== 第 4 部分:调用界面构建方法 =====
self.setup_ui() # ① 将界面搭建逻辑分离到单独的方法中
# ===== 作用总结:让 __init__ 方法保持简洁,界面代码集中管理 =====
def setup_ui(self):
"""
构建对话框界面:参数表单 + 日志区域 + 按钮栏。
"""
# ===== 第 1 部分:创建主垂直布局 =====
main_layout = QVBoxLayout(self) # ① 将垂直布局设置为对话框的主布局
# ===== 作用总结:后续所有控件都将按从上到下的顺序排列 =====
# ----- 参数表单区域(使用 QFormLayout 整齐排列标签和输入控件)-----
# ===== 第 2 部分:创建表单布局容器 =====
form_widget = QWidget() # ① 创建一个空白 QWidget 作为表单容器
form_layout = QFormLayout(form_widget) # ② 为该容器设置表单布局(两列:标签列+输入控件列)
form_layout.setLabelAlignment(Qt.AlignRight) # ③ 设置标签文字右对齐,更美观
# ===== 作用总结:准备好一个两列表单,用于放置参数标签和对应的输入控件 =====
# ===== 第 3 部分:遍历任务配置中的参数列表,动态创建输入控件 =====
for param_def in self.task_config["params"]:
param_name = param_def["name"] # ① 参数变量名
param_label = param_def["label"] # ② 界面显示标签
param_type = param_def["type"] # ③ 参数类型(file/folder/text/int/combo)
default_val = self.saved_params.get(param_name, param_def.get("default", "")) # ④ 获取默认值:优先使用记忆值,其次配置中的默认值
# 根据参数类型创建不同的输入控件
if param_type == "file":
# --- 文件选择:水平布局包含 QLineEdit 和“浏览”按钮 ---
# ===== 第 4-1 部分:创建文件选择控件 =====
container = QWidget() # ① 创建一个容器部件
h_layout = QHBoxLayout(container) # ② 为容器设置水平布局
h_layout.setContentsMargins(0, 0, 0, 0) # ③ 去除布局边距,使控件紧凑
edit = QLineEdit(str(default_val)) # ④ 创建文本输入框,并填入默认值
btn = QPushButton("浏览...") # ⑤ 创建“浏览”按钮
btn.clicked.connect(lambda checked, e=edit, p=param_def: self.browse_file(e, p)) # ⑥ 连接点击信号,调用 browse_file 方法
h_layout.addWidget(edit) # ⑦ 将输入框加入水平布局
h_layout.addWidget(btn) # ⑧ 将按钮加入水平布局
form_layout.addRow(param_label + ":", container) # ⑨ 将整行添加到表单布局
self.param_widgets[param_name] = edit # ⑩ 将输入框保存到控件字典中
# ===== 作用总结:创建一个带“浏览”按钮的文件路径输入行 =====
elif param_type == "folder":
# --- 文件夹选择:水平布局包含 QLineEdit 和“浏览”按钮 ---
container = QWidget()
h_layout = QHBoxLayout(container)
h_layout.setContentsMargins(0, 0, 0, 0)
edit = QLineEdit(str(default_val))
btn = QPushButton("浏览...")
btn.clicked.connect(lambda checked, e=edit: self.browse_folder(e))
h_layout.addWidget(edit)
h_layout.addWidget(btn)
form_layout.addRow(param_label + ":", container)
self.param_widgets[param_name] = edit
# ===== 作用总结:创建一个带“浏览”按钮的文件夹路径输入行 =====
elif param_type == "int":
# --- 整数输入:使用 QSpinBox ---
spin = QSpinBox() # ① 创建整数调节框
spin.setRange(0, 999999) # ② 设置取值范围
spin.setValue(int(default_val) if default_val else 0) # ③ 设置默认值
form_layout.addRow(param_label + ":", spin) # ④ 添加到表单
self.param_widgets[param_name] = spin # ⑤ 保存控件引用
# ===== 作用总结:创建一个只能输入整数的调节框 =====
elif param_type == "combo":
# --- 下拉选择:QComboBox ---
combo = QComboBox() # ① 创建下拉选择框
combo.addItems(param_def.get("options", [])) # ② 添加选项列表
if default_val in param_def.get("options", []):
combo.setCurrentText(default_val) # ③ 如果默认值在选项中,设为当前选中项
form_layout.addRow(param_label + ":", combo)
self.param_widgets[param_name] = combo
# ===== 作用总结:创建一个下拉选择框,用户只能从预设选项中选择 =====
else: # 默认为文本输入
# --- 普通文本输入:QLineEdit ---
edit = QLineEdit(str(default_val)) # ① 创建文本输入框
form_layout.addRow(param_label + ":", edit)
self.param_widgets[param_name] = edit
# ===== 作用总结:创建一个普通的单行文本输入框 =====
main_layout.addWidget(form_widget) # 将表单区域加入主布局
# ----- 日志区域 -----
# ===== 第 5 部分:创建日志显示框 =====
main_layout.addWidget(QLabel("运行日志:")) # ① 添加一个标签“运行日志:”
self.log_text = QTextEdit() # ② 创建多行文本编辑框
self.log_text.setReadOnly(True) # ③ 设置为只读,用户不可编辑
main_layout.addWidget(self.log_text) # ④ 加入主布局
# ===== 作用总结:添加一个只读的多行文本框,用于显示处理过程中的日志信息 =====
# ----- 底部按钮栏 -----
# ===== 第 6 部分:创建底部按钮栏 =====
btn_layout = QHBoxLayout() # ① 创建水平布局用于放置按钮
self.run_btn = QPushButton("运行") # ② 创建“运行”按钮
self.run_btn.clicked.connect(self.start_processing) # ③ 连接点击信号到槽函数
btn_layout.addWidget(self.run_btn)
self.stop_btn = QPushButton("停止") # ④ 创建“停止”按钮
self.stop_btn.setEnabled(False) # ⑤ 初始状态为禁用(灰色)
self.stop_btn.clicked.connect(self.stop_processing) # ⑥ 连接点击信号
btn_layout.addWidget(self.stop_btn)
self.close_btn = QPushButton("关闭") # ⑦ 创建“关闭”按钮
self.close_btn.clicked.connect(self.close) # ⑧ 连接点击信号到对话框的 close 方法
btn_layout.addWidget(self.close_btn)
main_layout.addLayout(btn_layout) # ⑨ 将按钮栏加入主布局
# ===== 作用总结:添加“运行”、“停止”、“关闭”三个操作按钮 =====
# --------------------------------------------------------------------------
# 辅助方法:文件/文件夹浏览(新增代码,三段式注解)
# --------------------------------------------------------------------------
def browse_file(self, line_edit, param_def):
"""
弹出文件选择对话框,并将选中的文件路径填入输入框。
"""
# ===== 第 1 部分:弹出文件选择对话框 =====
file_filter = "Excel 文件 (*.xlsx *.xls);;所有文件 (*.*)" # ① 定义文件类型过滤器
file_path, _ = QFileDialog.getOpenFileName(
self, # ② 父窗口
f"选择 {param_def['label']}", # ③ 对话框标题
line_edit.text() or os.getcwd(), # ④ 默认打开路径:若输入框有内容则用其目录,否则用当前工作目录
file_filter # ⑤ 文件过滤器
)
# ===== 作用总结:让用户通过图形界面选择一个文件,返回其完整路径字符串 =====
# ===== 第 2 部分:将选中的路径填入输入框 =====
if file_path: # ① 如果用户没有取消(即选择了文件)
line_edit.setText(file_path) # ② 将输入框内容更新为选中的文件路径
# ===== 作用总结:把用户选择的路径显示在界面上 =====
def browse_folder(self, line_edit):
"""
弹出文件夹选择对话框,并将选中的文件夹路径填入输入框。
"""
# ===== 第 1 部分:弹出文件夹选择对话框 =====
folder = QFileDialog.getExistingDirectory(
self, # ① 父窗口
"选择文件夹", # ② 对话框标题
line_edit.text() or os.getcwd() # ③ 默认打开路径
)
# ===== 作用总结:让用户选择一个文件夹,返回其完整路径 =====
# ===== 第 2 部分:将选中的路径填入输入框 =====
if folder: # ① 如果用户没有取消
line_edit.setText(folder) # ② 更新输入框内容
# ===== 作用总结:把用户选择的文件夹路径显示在界面上 =====
# --------------------------------------------------------------------------
# 收集参数值并校验(新增代码,三段式注解)
# --------------------------------------------------------------------------
def collect_params(self):
"""
从界面控件读取用户输入,返回参数字典。若必填项为空,返回 None。
"""
# ===== 第 1 部分:遍历所有参数定义,从控件中读取值 =====
params = {}
for param_def in self.task_config["params"]:
name = param_def["name"] # ① 参数名
widget = self.param_widgets[name] # ② 对应的输入控件
if isinstance(widget, QLineEdit):
value = widget.text().strip() # ③ 文本框取值并去除首尾空格
elif isinstance(widget, QSpinBox):
value = widget.value() # ④ 调节框取值(整数)
elif isinstance(widget, QComboBox):
value = widget.currentText() # ⑤ 下拉框取值(字符串)
else:
value = ""
# ===== 第 2 部分:必填校验 =====
if param_def.get("required", False) and not value: # ① 如果该参数标记为必填且值为空
QMessageBox.warning(self, "警告", f"参数 '{param_def['label']}' 不能为空!")
return None # ② 校验失败,返回 None
# ===== 作用总结:确保所有必填参数都已填写,否则提示用户并终止 =====
params[name] = value # ③ 将有效的参数值存入字典
# ===== 作用总结:返回一个包含所有参数名和对应值的字典 =====
return params
# --------------------------------------------------------------------------
# 启动处理(新增代码,三段式注解)
# --------------------------------------------------------------------------
def start_processing(self):
"""
用户点击“运行”按钮后执行。
"""
# ===== 第 1 部分:收集并校验参数 =====
params = self.collect_params()
if params is None:
return # 校验失败,直接返回
# ===== 作用总结:获取用户输入的所有参数,若校验不通过则停止执行 =====
# ===== 第 2 部分:保存当前参数到配置文件(记忆功能) =====
task_id = self.task_config["id"] # ① 获取当前任务 ID
self.all_config[task_id] = params # ② 将本次参数存入总配置字典
save_all_config(self.all_config) # ③ 将总配置字典写入 JSON 文件
# ===== 作用总结:实现参数记忆,下次打开该任务时自动填充本次使用的值 =====
# ===== 第 3 部分:切换按钮状态,清空日志 =====
self.run_btn.setEnabled(False) # ① “运行”按钮变灰,防止重复点击
self.stop_btn.setEnabled(True) # ② “停止”按钮变为可用
self.log_text.clear() # ③ 清空上一次的日志内容
# ===== 作用总结:界面进入“运行中”状态,准备显示新的日志 =====
# ===== 第 4 部分:获取实际处理函数 =====
func_name = self.task_config["function"] # ① 从任务配置中取出函数名字符串
task_func = FUNCTION_MAP.get(func_name) # ② 通过映射字典获取真正的函数对象
if task_func is None: # ③ 如果找不到函数
QMessageBox.critical(self, "错误", f"未找到处理函数 '{func_name}'")
self.run_btn.setEnabled(True) # ④ 恢复按钮状态
self.stop_btn.setEnabled(False)
return
# ===== 作用总结:根据配置找到要执行的处理函数,若配置错误则提示并恢复界面 =====
# ===== 第 5 部分:创建并启动工作线程 =====
self.worker = WorkerThread(task_func, params) # ① 实例化工作线程,传入处理函数和参数
self.worker.log_signal.connect(self.append_log) # ② 连接日志信号到槽函数,实现日志显示
self.worker.finished_signal.connect(self.on_worker_finished) # ③ 连接完成信号到槽函数
self.worker.start() # ④ 启动线程
# ===== 作用总结:在后台启动数据处理任务,界面保持响应 =====
# --------------------------------------------------------------------------
# 停止处理(新增代码,三段式注解)
# --------------------------------------------------------------------------
def stop_processing(self):
"""
用户点击“停止”按钮后执行。
"""
# ===== 第 1 部分:检查是否有正在运行的线程 =====
if self.worker and self.worker.isRunning(): # ① worker 非空且线程正在运行
self.worker.stop() # ② 调用工作线程的 stop() 方法,设置停止标志
self.stop_btn.setEnabled(False) # ③ 立即禁用停止按钮,防止重复点击
# ===== 作用总结:向后台线程发送停止指令,并让停止按钮变灰 =====
# --------------------------------------------------------------------------
# 线程完成后的清理工作(新增代码,三段式注解)
# --------------------------------------------------------------------------
def on_worker_finished(self):
"""
当工作线程执行完毕后自动调用。
"""
# ===== 第 1 部分:恢复按钮状态 =====
self.run_btn.setEnabled(True) # ① “运行”按钮重新可用
self.stop_btn.setEnabled(False) # ② “停止”按钮恢复禁用状态
# ===== 作用总结:界面恢复为就绪状态,等待用户下一次操作 =====
# ===== 第 2 部分:追加结束提示并弹窗 =====
self.append_log("任务线程已结束。") # ① 在日志最后添加一行明确提示
QMessageBox.information(self, "提示", "任务执行完毕,请查看日志。") # ② 弹出信息框提醒用户
# ===== 作用总结:明确告知用户任务已结束 =====
# --------------------------------------------------------------------------
# 向日志框追加文本(新增代码,三段式注解)
# --------------------------------------------------------------------------
def append_log(self, text):
"""
接收信号传来的字符串,显示在日志框中。
"""
# ===== 第 1 部分:调用 QTextEdit 的 append 方法 =====
self.log_text.append(text) # ① 在日志框末尾追加一行文本,并自动换行
# ===== 作用总结:实时显示后台处理进度和结果 =====
# --------------------------------------------------------------------------
# 重写关闭事件(新增代码,三段式注解)
# --------------------------------------------------------------------------
def closeEvent(self, event):
"""
当用户点击右上角“X”或调用 close() 时触发。
"""
# ===== 第 1 部分:检查是否有任务正在运行 =====
if self.worker and self.worker.isRunning(): # ① 如果后台线程还在跑
reply = QMessageBox.question(
self, "确认", "任务正在运行,确定要关闭窗口吗?",
QMessageBox.Yes | QMessageBox.No
) # ② 弹出询问对话框,让用户二选一
# ===== 作用总结:防止用户误关窗口导致后台线程残留 =====
# ===== 第 2 部分:根据用户选择决定是否关闭 =====
if reply == QMessageBox.Yes: # ① 用户选择“是”
self.worker.stop() # ② 发送停止指令
self.worker.wait(2000) # ③ 等待最多2秒,让线程有机会退出
event.accept() # ④ 接受关闭事件,窗口正常关闭
else:
event.ignore() # ⑤ 用户选择“否”,忽略事件,窗口不关闭
else:
event.accept() # ⑥ 没有任务运行,直接关闭
# ===== 作用总结:确保关闭窗口前妥善处理后台线程,避免程序崩溃或资源泄露 =====
# ==============================================================================
# 【区域六:主窗口类(新增代码,三段式注解)】
# ==============================================================================
class MainWindow(QMainWindow):
"""
应用程序主窗口,包含菜单栏,用于启动各个任务。
"""
def __init__(self):
# ===== 第 1 部分:调用父类构造函数并设置窗口属性 =====
super().__init__() # ① 初始化 QMainWindow 基类
self.setWindowTitle("数据处理工具集") # ② 设置主窗口标题
self.setMinimumSize(400, 300) # ③ 设置最小尺寸
# ===== 作用总结:创建一个标题为“数据处理工具集”的主窗口 =====
# ===== 第 2 部分:创建菜单栏并动态生成分类菜单 =====
menubar = self.menuBar() # ① 获取窗口的菜单栏对象
category_menus = {} # ② 字典,用于存储已创建的分类菜单对象
for task in TASK_LIST: # ③ 遍历任务配置列表
category = task.get("category", "默认分类") # ④ 获取任务的分类,若无则用“默认分类”
if category not in category_menus: # ⑤ 如果该分类菜单尚未创建
category_menus[category] = menubar.addMenu(category) # ⑥ 创建新菜单并保存
menu = category_menus[category] # ⑦ 获取对应的菜单对象
action = menu.addAction(task["name"]) # ⑧ 在菜单下添加一个动作(菜单项)
action.setStatusTip(task.get("description", "")) # ⑨ 设置状态栏提示(鼠标悬停时显示)
# 使用 lambda 捕获当前 task,避免循环变量覆盖问题
action.triggered.connect(lambda checked, t=task: self.open_task_dialog(t)) # ⑩ 连接点击信号
# ===== 作用总结:根据 TASK_LIST 自动生成分类菜单,每个任务对应一个菜单项 =====
# ===== 第 3 部分:设置中央空白区域 =====
central_widget = QWidget() # ① 创建一个空白 QWidget 作为中央部件
self.setCentralWidget(central_widget) # ② 将其设置为主窗口的中央区域
layout = QVBoxLayout(central_widget) # ③ 为中央部件设置垂直布局
label = QLabel("请从上方菜单中选择要执行的数据处理任务") # ④ 创建一个提示标签
label.setAlignment(Qt.AlignCenter) # ⑤ 文字水平垂直居中
layout.addWidget(label) # ⑥ 将标签放入布局
# ===== 作用总结:主窗口中央显示一行提示文字,引导用户点击菜单 =====
# --------------------------------------------------------------------------
# 打开任务对话框(新增代码,三段式注解)
# --------------------------------------------------------------------------
def open_task_dialog(self, task_config):
"""
菜单项点击后执行的槽函数。
"""
# ===== 第 1 部分:创建对话框实例 =====
dialog = TaskDialog(task_config, self) # ① 实例化 TaskDialog,传入任务配置和主窗口作为父对象
# ===== 作用总结:生成对应任务的参数输入对话框 =====
# ===== 第 2 部分:以模态方式显示对话框 =====
dialog.exec() # ① exec() 方法使对话框成为模态窗口(必须关闭才能操作主窗口)
# ===== 作用总结:显示对话框,并阻塞主窗口交互,直到用户关闭对话框 =====
# ==============================================================================
# 【区域七:程序入口(新增代码,三段式注解)】
# ==============================================================================
if __name__ == "__main__":
# ===== 第 1 部分:创建 QApplication 实例 =====
app = QApplication(sys.argv) # ① 任何 PySide6 程序都必须有 QApplication 对象,sys.argv 用于处理命令行参数
# ===== 作用总结:初始化 Qt 应用程序框架 =====
# ===== 第 2 部分:创建并显示主窗口 =====
window = MainWindow() # ① 实例化我们定义的主窗口类
window.show() # ② 显示窗口
# ===== 作用总结:让主窗口出现在屏幕上 =====
# ===== 第 3 部分:启动事件循环 =====
sys.exit(app.exec()) # ① app.exec() 进入 Qt 主事件循环,等待用户操作;程序结束时返回退出码
# ===== 作用总结:保持程序运行,直到用户关闭主窗口 =====